package org.unidata.mdm.rest.data.service.impl;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.unidata.mdm.core.service.MetaModelService;
import org.unidata.mdm.core.type.data.OperationType;
import org.unidata.mdm.core.type.model.AttributeElement;
import org.unidata.mdm.core.type.model.AttributedElement;
import org.unidata.mdm.core.type.model.EntityElement;
import org.unidata.mdm.core.type.model.IndexedElement;
import org.unidata.mdm.core.type.security.SecurityLabel;
import org.unidata.mdm.core.util.SecurityUtils;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.meta.type.search.EntityIndexType;
import org.unidata.mdm.meta.type.search.EtalonIndexType;
import org.unidata.mdm.meta.type.search.RecordHeaderField;
import org.unidata.mdm.meta.type.search.RelationHeaderField;
import org.unidata.mdm.rest.data.service.SearchResultHitModifier;
import org.unidata.mdm.rest.search.SearchRequestDataType;
import org.unidata.mdm.rest.search.converter.SearchFieldsConverter;
import org.unidata.mdm.rest.search.converter.SearchResultToRestSearchResultConverter;
import org.unidata.mdm.rest.search.ro.SearchComplexRO;
import org.unidata.mdm.rest.search.ro.SearchResultRO;
import org.unidata.mdm.rest.search.ro.SearchSortFieldRO;
import org.unidata.mdm.rest.search.type.rendering.SearchRestInputRenderingAction;
import org.unidata.mdm.rest.search.type.rendering.SearchRestOutputRenderingAction;
import org.unidata.mdm.search.configuration.SearchConfigurationConstants;
import org.unidata.mdm.search.context.ComplexSearchRequestContext.ComplexSearchRequestContextBuilder;
import org.unidata.mdm.search.context.ComplexSearchRequestContext.ComplexSearchRequestType;
import org.unidata.mdm.search.context.SearchInputCollector;
import org.unidata.mdm.search.context.SearchInputCollector.SearchInputCollectorType;
import org.unidata.mdm.search.context.SearchRequestContext;
import org.unidata.mdm.search.context.SearchRequestContext.SearchRequestContextBuilder;
import org.unidata.mdm.search.dto.ComplexSearchResultDTO;
import org.unidata.mdm.search.dto.SearchOutputContainer;
import org.unidata.mdm.search.dto.SearchOutputContainer.SearchOutputContainerType;
import org.unidata.mdm.search.dto.SearchResultDTO;
import org.unidata.mdm.search.dto.SearchResultHitDTO;
import org.unidata.mdm.search.exception.SearchExceptionIds;
import org.unidata.mdm.search.type.IndexField;
import org.unidata.mdm.search.type.IndexType;
import org.unidata.mdm.search.type.form.FieldsGroup;
import org.unidata.mdm.search.type.form.FormField;
import org.unidata.mdm.search.type.query.FormSearchQuery;
import org.unidata.mdm.search.type.query.SearchQuery;
import org.unidata.mdm.search.type.search.FacetName;
import org.unidata.mdm.search.type.sort.SortField;
import org.unidata.mdm.search.util.SearchUtils;
import org.unidata.mdm.system.dto.OutputContainer;
import org.unidata.mdm.system.exception.PlatformFailureException;
import org.unidata.mdm.system.type.annotation.ConfigurationRef;
import org.unidata.mdm.system.type.configuration.ConfigurationValue;
import org.unidata.mdm.system.type.rendering.InputFragmentRenderer;
import org.unidata.mdm.system.type.rendering.InputRenderingAction;
import org.unidata.mdm.system.type.rendering.OutputFragmentRenderer;
import org.unidata.mdm.system.type.rendering.OutputRenderingAction;
import org.unidata.mdm.system.type.rendering.OutputSink;
import org.unidata.mdm.system.type.rendering.RenderingProvider;

/**
 * Search rendering routines.
 * @author Mikhail Mikhailov on Mar 18, 2020
 */
@Component
public class SearchDataRenderingProvider implements RenderingProvider {
    /**
     * Record system fields.
     */
    private static final Map<String, RecordHeaderField> RECORD_HEADER_FIELDS_BY_NAME;
    /**
     * Relation system fields.
     */
    private static final Map<String, RelationHeaderField> RELATION_HEADER_FIELDS_BY_NAME;

    /**
     * Static initializer.
     */
    static {

        RECORD_HEADER_FIELDS_BY_NAME = new HashMap<>();
        for (RecordHeaderField rhf : RecordHeaderField.values()) {
            RECORD_HEADER_FIELDS_BY_NAME.put(rhf.getPath(), rhf);
        }

        RELATION_HEADER_FIELDS_BY_NAME = new HashMap<>();
        for (RelationHeaderField rhf : RelationHeaderField.values()) {
            RELATION_HEADER_FIELDS_BY_NAME.put(rhf.getPath(), rhf);
        }
    }

    @ConfigurationRef(SearchConfigurationConstants.PROPERTY_CALCULATE_SCORE)
    private ConfigurationValue<Boolean> calculateScore;
    /**
     * MMS link.
     */
    @Autowired
    private MetaModelService metaModelService;
    /**
     * Modify search result for ui presentation
     */
    @Autowired
    private SearchResultHitModifier searchResultHitModifier;
    /**
     * The data search output finalizer - runs RO converter finally.
     * @author Mikhail Mikhailov on Mar 18, 2020
     */
    private class HierarchicalSearchOutputFinalizer implements OutputFragmentRenderer {

        @Override
        public void render(String version, OutputContainer container, OutputSink sink) {

            SearchOutputContainer output = (SearchOutputContainer) container;
            SearchResultRO result = (SearchResultRO) sink;

            if (output.getContainerType() == SearchOutputContainerType.COMPLEX) {

                ComplexSearchResultDTO collected = (ComplexSearchResultDTO) output;
                if (collected.getComplexSearchType() == ComplexSearchRequestType.HIERARCHICAL) {

                    SearchResultDTO main = collected.getMain();
                    searchResultHitModifier.modifySearchResult(main);
                    SearchResultRO local = SearchResultToRestSearchResultConverter.convert(main);

                    result.setFields(local.getFields());
                    result.setHasRecords(local.isHasRecords());
                    result.setMaxScore(local.getMaxScore());
                    result.setSuccess(local.isSuccess());
                    result.setTotalCount(local.getTotalCount());
                    result.setTotalCountLimit(local.getTotalCountLimit());
                    result.getHits().addAll(local.getHits());
                }
            }
        }
        /**
         * {@inheritDoc}
         */
        @Override
        public int order() {
            return 1000;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Collection<InputFragmentRenderer> get(InputRenderingAction action) {

        if (action.isOneOf(SearchRestInputRenderingAction.values())) {
            return Collections.singletonList((v, c, s) -> renderSearchInput((SearchInputCollector) c, (SearchComplexRO) s));
        }

        return Collections.emptyList();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Collection<OutputFragmentRenderer> get(OutputRenderingAction action) {

        if (action.isOneOf(SearchRestOutputRenderingAction.values())) {
            return Arrays.asList(
                    (v, c, s) -> renderSearchOutput((SearchOutputContainer) c, (SearchResultRO) s),
                    new HierarchicalSearchOutputFinalizer());
        }

        return Collections.emptyList();
    }

    private void renderSearchInput(SearchInputCollector result, SearchComplexRO request) {
        if (result.getCollectorType() == SearchInputCollectorType.COMPLEX) {
            renderComplexSearchInput((ComplexSearchRequestContextBuilder) result, request);
        } else if (result.getCollectorType() == SearchInputCollectorType.SIMPLE) {
            renderSimpleSearchInput((SearchRequestContextBuilder) result, request);
        }
    }

    private void renderComplexSearchInput(ComplexSearchRequestContextBuilder csrcb, SearchComplexRO request) {

        if (request.getDataType() == SearchRequestDataType.ETALON
         || request.getDataType() == SearchRequestDataType.ETALON_DATA
         || request.getDataType() == SearchRequestDataType.ETALON_REL) {

            SearchRequestContextBuilder main = SearchRequestContext.builder(request.getEntity());
            renderSimpleSearchInput(main, request);

            csrcb.main(main.build());
        }

        for (SearchComplexRO supplementary : request.getSupplementaryRequests()) {

            if (supplementary.getDataType() != SearchRequestDataType.ETALON
             && supplementary.getDataType() != SearchRequestDataType.ETALON_DATA
             && supplementary.getDataType() != SearchRequestDataType.ETALON_REL) {
                continue;
            }

           SearchRequestContextBuilder ctx = SearchRequestContext.builder(supplementary.getEntity());
           renderSimpleSearchInput(ctx, supplementary);

           csrcb.supplementary(ctx.build());
        }
    }

    /**
     * Set up search context(s) for data processing.
     * @param srcb
     * @param source
     */
    private void renderSimpleSearchInput(SearchRequestContextBuilder srcb, SearchComplexRO source) {

        IndexType target;
        SearchRequestDataType type = source.getDataType();
        switch (type) {
            case ETALON:
                target = EtalonIndexType.ETALON;
                break;
            case ETALON_DATA:
                target = EntityIndexType.RECORD;
                break;
            case ETALON_REL:
                target = EntityIndexType.RELATION;
                break;
            default:
                // This is probably not our business.
                return;
        }

        // 1. Type
        srcb.type(target);

        if (target != EtalonIndexType.ETALON) {

            EntityElement el = metaModelService.instance(Descriptors.DATA).getElement(source.getEntity());

            Map<String, IndexField> fieldSpace = target == EntityIndexType.RECORD
                    ? new HashMap<>(RECORD_HEADER_FIELDS_BY_NAME)
                    : new HashMap<>(RELATION_HEADER_FIELDS_BY_NAME);

            IndexedElement iel = el.getIndexed();
            fieldSpace.putAll(iel.getIndexFields());

            // 2. Has (multi) match query part
            srcb.query(renderMatchSearchQuery(source, fieldSpace));

            // 3. Facets. Convert them to fields query part
            srcb.filter((FormSearchQuery) renderFacetsFilter(source, target, el));

            // 4. Form
            srcb.query(renderFormFields(source, fieldSpace));

            // 5. Form field groups
            srcb.query(renderFormGroups(source, fieldSpace));

            // 5. Return fields
            srcb.returnFields(renderReturnFields(source, target));

            // 6. Sorting
            srcb.sorting(renderSortFields(source, target, fieldSpace));

            // 7.Score calculation
            srcb.enableScore(!source.isFetchAll() && calculateScore.getValue());

            // 8. Join field
            srcb.joinBy(target == EntityIndexType.RECORD ? RecordHeaderField.FIELD_ETALON_ID : RelationHeaderField.FIELD_FROM_ETALON_ID);
        }

        // 8. The rest
        srcb.count(source.getCount())
            .page(source.getPage() > 0 ? source.getPage() - 1 : source.getPage())
            .source(source.isSource())
            .totalCount(source.isTotalCount())
            .countOnly(source.isCountOnly())
            .fetchAll(source.isFetchAll())
            .sayt(source.isSayt())
            .runExits(true)
            .searchAfter(source.getSearchAfter());
    }

    private SearchQuery renderMatchSearchQuery(SearchComplexRO source, Map<String, IndexField> fieldSpace) {

        if (StringUtils.isBlank(source.getText())
         || CollectionUtils.isEmpty(source.getSearchFields())) {
            return null;
        }

        // 2. Has (multi) match query part
        List<IndexField> fields = new ArrayList<>(source.getSearchFields().size());
        for (String sf : source.getSearchFields()) {

            IndexField field = fieldSpace.get(sf);
            if (Objects.nonNull(field)) {
                fields.add(field);
            }
        }

        if (CollectionUtils.isNotEmpty(fields)) {
            return SearchQuery.matchQuery(source.getText(), false, fields);
        }

        return null;
    }

    private SearchQuery renderFacetsFilter(SearchComplexRO source, IndexType target, AttributedElement el) {

        Map<FacetName, Boolean> facets = FacetName.mapFromValues(source.getFacets());

        if (facets.containsKey(FacetName.FACET_NAME_ACTIVE_ONLY) && facets.containsKey(FacetName.FACET_NAME_INACTIVE_ONLY)) {
            throw new PlatformFailureException("Those facets can't be applied together:" + facets.toString(),
                    SearchExceptionIds.EX_SEARCH_UNAVAILABLE_FACETS_COMBINATION);
        }

        FieldsGroup and = FieldsGroup.and();

        // 1. Active / inactive records filtering
        if (facets.containsKey(FacetName.FACET_NAME_INACTIVE_ONLY)) {
            and.add(FormField.exact(target == EntityIndexType.RECORD
                    ? RecordHeaderField.FIELD_DELETED
                    : RelationHeaderField.FIELD_DELETED, Boolean.TRUE));
        // Add active default
        } else {
            and.add(FormField.exact(target == EntityIndexType.RECORD
                    ? RecordHeaderField.FIELD_DELETED
                    : RelationHeaderField.FIELD_DELETED, Boolean.FALSE));
        }

        // 2. Active / inactive periods filtering
        boolean includeActivePeriods = facets.containsKey(FacetName.FACET_NAME_ACTIVE_PERIODS);
        boolean includeInactivePeriods = facets.containsKey(FacetName.FACET_NAME_INACTIVE_PERIODS);
        // Don't filter for both facets set active
        if ((includeActivePeriods && !includeInactivePeriods) || (!includeActivePeriods && !includeInactivePeriods)) {
            and.add(FormField.exact(target == EntityIndexType.RECORD
                    ? RecordHeaderField.FIELD_INACTIVE
                    : RelationHeaderField.FIELD_INACTIVE, Boolean.FALSE));
        } else if (!includeActivePeriods) {
            and.add(FormField.exact(target == EntityIndexType.RECORD
                    ? RecordHeaderField.FIELD_INACTIVE
                    : RelationHeaderField.FIELD_INACTIVE, Boolean.TRUE));
        }

        // 3. All periods (unranged)
        if (!facets.containsKey(FacetName.FACET_UN_RANGED)) {

            Date point = source.getAsOf() == null ? new Date() : source.getAsOf();
            if (target == EntityIndexType.RECORD) {
                and.add(FieldsGroup.and(
                        FormField.range(RecordHeaderField.FIELD_FROM, null, point),
                        FormField.range(RecordHeaderField.FIELD_TO, point, null)));
            } else {
                and.add(FieldsGroup.and(
                        FormField.range(RelationHeaderField.FIELD_FROM, null, point),
                        FormField.range(RelationHeaderField.FIELD_TO, point, null)));
            }
        }

        // 4. Operation types
        if (target == EntityIndexType.RECORD) {

            List<OperationType> ot = new ArrayList<>();
            if (facets.containsKey(FacetName.FACET_NAME_OPERATION_TYPE_CASCADED)) {
                ot.add(OperationType.CASCADED);
            }

            if (facets.containsKey(FacetName.FACET_NAME_OPERATION_TYPE_COPY)) {
                ot.add(OperationType.COPY);
            }

            if (facets.containsKey(FacetName.FACET_NAME_OPERATION_TYPE_DIRECT)) {
                ot.add(OperationType.DIRECT);
            }

            if (CollectionUtils.isNotEmpty(ot)) {
                and.add(FormField.exact(RecordHeaderField.FIELD_OPERATION_TYPE, ot.stream()
                            .map(OperationType::name)
                            .collect(Collectors.toList())));
            }
        }

        // 5. Security filters
        List<SecurityLabel> labels = SecurityUtils.getSecurityLabelsForResource(((EntityElement) el).getName());
        if (CollectionUtils.isNotEmpty(labels)) {

            final Map<String, List<SecurityLabel>> grouped = labels.stream()
                    .collect(Collectors.groupingBy(SecurityLabel::getName));

            for (Entry<String, List<SecurityLabel>> group : grouped.entrySet()) {

                FieldsGroup osg = FieldsGroup.or();
                for (final SecurityLabel sl : group.getValue()) {

                    FieldsGroup asg = FieldsGroup.and();
                    sl.getAttributes().forEach(sla -> {

                        // First component before '.' separator is the entity name
                        final String attrPath = StringUtils.substringAfter(sla.getPath(), ".");
                        if (StringUtils.isNotEmpty(sla.getValue())) {

                            AttributeElement ame = el.getAttributes().get(attrPath);
                            if (ame.isIndexed()) {
                                asg.add(FormField.exact(ame.getIndexed(), sla.getValue()));
                            }
                        }
                    });

                    if (!asg.isEmpty()) {
                        osg.add(asg);
                    }
                }

                if (!osg.isEmpty()) {
                    and.add(osg);
                }
            }
        }

        if (!and.isEmpty()) {
            return SearchQuery.formQuery(and);
        }

        return null;
    }

    private List<String> renderReturnFields(SearchComplexRO source, IndexType target) {

        List<String> returnFields = source.getReturnFields();
        if (returnFields == null) {
            returnFields = new ArrayList<>();
        }

        if (target == EntityIndexType.RECORD) {
            returnFields.add(RecordHeaderField.FIELD_DELETED.getName());
            returnFields.add(RecordHeaderField.FIELD_ETALON_ID.getName());
        } else {
            returnFields.add(RelationHeaderField.FIELD_DELETED.getName());
            returnFields.add(RelationHeaderField.FIELD_ETALON_ID.getName());
            returnFields.add(RelationHeaderField.FIELD_FROM_ETALON_ID.getName());
            returnFields.add(RelationHeaderField.FIELD_TO_ETALON_ID.getName());
        }

        return returnFields;
    }

    private Collection<SortField> renderSortFields(SearchComplexRO source, IndexType target, Map<String, IndexField> fieldSpace) {

        Collection<SearchSortFieldRO> fields = source.getSortFields();
        if (CollectionUtils.isEmpty(fields)) {
            if (target == EntityIndexType.RECORD) {
                return Collections.singleton(SortField.of(RecordHeaderField.FIELD_CREATED_AT, SortField.SortOrder.DESC));
            } else {
                return Collections.singleton(SortField.of(RelationHeaderField.FIELD_CREATED_AT, SortField.SortOrder.DESC));
            }
        }

        Collection<SortField> sortFields = new ArrayList<>(fields.size());
        for (SearchSortFieldRO sortFieldRO : fields) {

            IndexField sortRef = fieldSpace.get(sortFieldRO.getField());
            if (Objects.isNull(sortRef)) {
                continue;
            }

            sortFields.add(SortField.of(sortRef, SortField.SortOrder.valueOf(sortFieldRO.getOrder())));
        }

        return sortFields;
    }

    private SearchQuery renderFormFields(SearchComplexRO source, Map<String, IndexField> fieldSpace) {

        if (CollectionUtils.isNotEmpty(source.getFormFields())) {

            List<FieldsGroup> groups = SearchFieldsConverter.convertFormFields(source.getFormFields(), fieldSpace);
            if (CollectionUtils.isNotEmpty(groups)) {
                return SearchQuery.formQuery(groups);
            }
        }

        return null;
    }

    private SearchQuery renderFormGroups(SearchComplexRO source, Map<String, IndexField> fieldSpace) {

        if (CollectionUtils.isNotEmpty(source.getFormGroups())) {

            List<FieldsGroup> groups = source.getFormGroups().stream()
                    .map(fg -> SearchFieldsConverter.convertFormFieldsGroup(fg, fieldSpace))
                    .collect(Collectors.toList());

            if (CollectionUtils.isNotEmpty(groups)) {
                return SearchQuery.formQuery(groups);
            }
        }

        return null;
    }

    private void renderSearchOutput(SearchOutputContainer container, SearchResultRO output) {
        if (container.getContainerType() == SearchOutputContainerType.COMPLEX) {
            renderComplexSearchOutput((ComplexSearchResultDTO) container, output);
        } else if (container.getContainerType() == SearchOutputContainerType.SIMPLE) {
            renderSimpleSearchOutput((SearchResultDTO) container, output);
        }
    }

    private void renderComplexSearchOutput(ComplexSearchResultDTO result, SearchResultRO output) {

        // 1. Multi
        if (result.getComplexSearchType() == ComplexSearchRequestType.MULTI) {
            result.getSupplementary().forEach(r -> renderSimpleSearchOutput(r, output));
            return;
        }

        // 2. Hierarchical. Rendering will actually be done in HierarchicalSearchOutputFinalizer
        Map<String, List<SearchResultHitDTO>> idsToHits = result.getMain().getHitsByOriginalJoinField();
        for (SearchResultDTO supplementary : result.getSupplementary()) {

            if (!supplementary.getIndexType().isOneOf(EntityIndexType.RECORD, EtalonIndexType.ETALON, EntityIndexType.RELATION)) {
                continue;
            }

            searchResultHitModifier.modifySearchResult(supplementary);

            for (SearchResultHitDTO hit : supplementary.getHits()) {

                String etalonId = hit.getFieldFirstValue(supplementary.getJoinField().getName());
                List<SearchResultHitDTO> mainHits = idsToHits.get(etalonId);
                if (CollectionUtils.isNotEmpty(mainHits)) {
                    mainHits.forEach(mainHit ->
                        mainHit.getPreview().putAll(
                            hit.getPreview().entrySet().stream()
                                    .filter(entry -> supplementary.getFields().contains(entry.getKey()) && !SearchUtils.isSystemField(entry.getKey())) // <- Why can't we return system fields?
                                    .collect(Collectors.toMap(Entry::getKey, Entry::getValue))));
                }
            }
        }
    }

    private void renderSimpleSearchOutput(SearchResultDTO result, SearchResultRO output) {

        if (!result.getIndexType().isOneOf(EtalonIndexType.ETALON, EntityIndexType.RECORD, EntityIndexType.RELATION)) {
            return;
        }

        searchResultHitModifier.modifySearchResult(result);

        SearchResultRO local = SearchResultToRestSearchResultConverter.convert(result);

        output.getHits().addAll(local.getHits());
        output.setFields(local.getFields());
        output.setHasRecords(local.isHasRecords());
        output.setMaxScore(local.getMaxScore());
        output.setSuccess(local.isSuccess());
        output.setTotalCount(output.getTotalCount() + local.getTotalCount());
        output.setTotalCountLimit(local.getTotalCountLimit());

        if (CollectionUtils.isEmpty(output.getErrors())) {
            output.setErrors(local.getErrors());
        } else {
            output.getErrors().addAll(local.getErrors());
        }

        if (CollectionUtils.isEmpty(output.getFields())) {
            output.setFields(local.getFields());
        } else {
            output.getFields().addAll(local.getFields());
        }
    }

}
